Skip to content

feat(RichTextInput): create new component#6318

Open
JulienSaguez wants to merge 8 commits intomainfrom
feat/rich-text-editor
Open

feat(RichTextInput): create new component#6318
JulienSaguez wants to merge 8 commits intomainfrom
feat/rich-text-editor

Conversation

@JulienSaguez
Copy link
Copy Markdown
Contributor

@JulienSaguez JulienSaguez commented Apr 9, 2026

Summary

Type

  • Feature

Summarize concisely:

What is expected?

RichTextInput: create component RichTextInput and RichTextInputField

The following changes were made:

(Describe what you did)

Relevant logs and/or screenshots

Page Before After
url screenshot screenshot
url screenshot screenshot

@JulienSaguez JulienSaguez self-assigned this Apr 9, 2026
@JulienSaguez JulienSaguez requested review from a team and lisalupi as code owners April 9, 2026 08:54
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 9, 2026

🦋 Changeset detected

Latest commit: 63e7331

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@ultraviolet/form Minor
@ultraviolet/ui Minor
@ultraviolet/nextjs Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 83.83838% with 32 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.44%. Comparing base (2b89546) to head (63e7331).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...ages/ui/src/compositions/RichTextInput/Toolbar.tsx 68.51% 15 Missing and 2 partials ⚠️
...kages/ui/src/compositions/RichTextInput/helpers.ts 64.70% 6 Missing ⚠️
...es/ui/src/compositions/RichTextInput/editorCore.ts 88.88% 4 Missing and 1 partial ⚠️
...kages/ui/src/compositions/RichTextInput/Notice.tsx 81.81% 2 Missing ⚠️
...ckages/ui/src/compositions/RichTextInput/index.tsx 95.83% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #6318      +/-   ##
==========================================
+ Coverage   83.30%   92.44%   +9.13%     
==========================================
  Files          44      540     +496     
  Lines         683    10234    +9551     
  Branches      195     3913    +3718     
==========================================
+ Hits          569     9461    +8892     
- Misses        104      706     +602     
- Partials       10       67      +57     
Files with missing lines Coverage Δ
...form/src/compositions/RichTextInputField/index.tsx 100.00% <100.00%> (ø)
...es/ui/src/compositions/RichTextInput/styles.css.ts 100.00% <100.00%> (ø)
...kages/ui/src/compositions/RichTextInput/Notice.tsx 81.81% <81.81%> (ø)
...ckages/ui/src/compositions/RichTextInput/index.tsx 95.83% <95.83%> (ø)
...es/ui/src/compositions/RichTextInput/editorCore.ts 88.88% <88.88%> (ø)
...kages/ui/src/compositions/RichTextInput/helpers.ts 64.70% <64.70%> (ø)
...ages/ui/src/compositions/RichTextInput/Toolbar.tsx 68.51% <68.51%> (ø)

... and 489 files with indirect coverage changes


Continue to review full report in Codecov by Sentry.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e4ae00d...63e7331. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

{...props}
error={getError(
{
label: label ?? ariaLabel ?? name,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
label: label ?? ariaLabel ?? name,
label: errorLabel ?? label ?? ariaLabel ?? name,

errorLabel is a new prop of BaseFieldProps that can be used to customize the error message (see #6241)

field.onChange(value)
onChange?.(value as PathValue<TFieldValues, Path<TFieldValues>>)
}}
value={field.value ?? ''}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why add ?? ''

)
},
],
title: 'Form/Components/Fields/RichTextEditorField',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: 'Form/Components/Fields/RichTextEditorField',
title: 'Form/Components/Compositions/RichTextEditorField',


export default {
component: RichTextEditor,
title: 'UI/Data Entry/RichTextEditor',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: 'UI/Data Entry/RichTextEditor',
title: 'Compositions/RichTextEditor',

Comment on lines +153 to +156
style={{
minHeight,
...(maxHeight ? { maxHeight } : {}),
}}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not add this in richTextEditorStyle.docRegion paired with assignInlineVars?

sentiment?: 'danger' | 'success' | 'neutral'
}) => (
<Row gap="1" templateColumns="minmax(0, 1fr) min-content">
<div>
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't understand the role of this div

})

export const docRegion = style({
lineHeight: 1.5,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
lineHeight: 1.5,
lineHeight: theme.typography.body.lineHeight,

Copy link
Copy Markdown
Collaborator

@lisalupi lisalupi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can copy-paste text that renders as h1, h2, etc., but it is not possible to write headings. Is it ok?

Image

)
},
],
title: 'Compositions/RichTextEditorField',
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: 'Compositions/RichTextEditorField',
title: 'Form/Components/Compositions/RichTextEditorField',

Comment on lines +75 to +78
const maxHeight =
typeof maxRows === 'number'
? `calc(${lineHeightEm}em * ${maxRows} + 2 * ${padding})`
: undefined
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const maxHeight =
typeof maxRows === 'number'
? `calc(${lineHeightEm}em * ${maxRows} + 2 * ${padding})`
: undefined
const maxHeight =
typeof maxRows === 'number'
? `calc(${lineHeightEm}em * ${maxRows} + 2 * ${padding})`
: 'none'

Just a suggestion

...assignInlineVars({
[docRegionMaxHeightVar]: maxHeight ?? 'none',
}),
minHeight,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minHeight should be an inlineVar too

const isInOrderedList = isSelectionInNodeType(editorState, orderedList)

return (
<Stack alignItems="center" direction="row" gap={1}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idéalement il faudrait un role="toolbar" comme dans l'exemple ProseMirror. La navigation clavier est aussi différente (flèches pour naviguer entre les boutons, et quand on clique dessus ça remet le focus dans l'éditeur).

disabled={disabled}
size="small"
variant={isMarkActive(editorState, strongMark) ? 'filled' : 'ghost'}
onMouseDown={event => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • utiliser onClick pour supporter le clavier, et qui est déclenché quand on relâche le clic. C'est plus courant que de faire l'action au MouseDown, avant d'avoir relâché
  • pourquoi event.preventDefault() ?
  • il faut un label explicite aux boutons, là c'est le nom de l'icône ("BoldIcon"). Je vois qu'on utilise par endroits des fichiers de traduction en.ts, tu pourrais faire pareil en attendant qu'on trouve une meilleure solution

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Utiliser onMouseDown permet runCommand avant le changement de focus
  • L'event.preventDefault permet d'empecher le comportement par defaut d'un clic sur un bouton qui est de prendre le focus
  • Je rajoute les labels

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dans la démo de prosemirror on peut cliquer sur les boutons de la toolbar au clavier et à la souris, et le champ garde le focus. Est-ce qu'ils donnent le code de ça pour faire pareil ? C'est un peu gênant de pouvoir focus les boutons au clavier mais qu'on peut pas cliquer dessus

return (
<Stack gap="0.5">
{label ? (
<Label htmlFor={id ?? localId} required={required}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues :

  • the localId is not used on the editor so the label points to nothing
  • a label element cannot be linked to a div. You can use an id on the label and aria-labelledby on the editor instead

}),
className,
)}
data-disabled={disabled ? 'true' : undefined}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need this data-disabled attribute ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Effectivement il n'est pas utile

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so you can remove it ?

disabled?: boolean
sentiment?: 'danger' | 'success' | 'neutral'
}) => (
<Row gap="1" templateColumns="minmax(0, 1fr) min-content">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the container of the Notice message should have:

  • an id paired with an aria-describedby attribute on the text editor to link the message to the editor
  • a role="status" which implicitely add an aria-live="polite" so that screen readers read the status message when it's updated

Comment thread packages/ui/src/compositions/RichTextInput/index.tsx
Comment thread packages/form/src/compositions/RichTextEditorField/__tests__/index.test.tsx Outdated
Comment thread packages/form/src/compositions/RichTextEditorField/__tests__/index.test.tsx Outdated
Comment thread packages/form/src/compositions/RichTextEditorField/__tests__/index.test.tsx Outdated
@JulienSaguez JulienSaguez changed the title feat(RichTextEditor): create new component feat(RichTextInput): create new component Apr 20, 2026
@JulienSaguez JulienSaguez requested a review from jsulpis April 20, 2026 14:55
}
await userEvent.click(doc)
await userEvent.type(doc, 'This is an example')
await userEvent.click(screen.getByText('Submit'))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

always use getByRole if possible. This is more precise, because you want to click on a button, not a text

Suggested change
await userEvent.click(screen.getByText('Submit'))
await userEvent.click(screen.getByRole('button', { name: 'Submit' }))

Comment on lines +78 to +81
const italicButton = screen.getByTitle('ItalicIcon').closest('button')
const bulletListButton = screen
.getByTitle('ListBulletIcon')
.closest('button')
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const italicButton = screen.getByTitle('ItalicIcon').closest('button')
const bulletListButton = screen
.getByTitle('ListBulletIcon')
.closest('button')
const italicButton = screen.getByRole('button', { name: "Italic" })
const bulletListButton = screen
.getByRole('button', { name: "Bullet List" })

await userEvent.type(doc, 'Styled ')
await userEvent.click(bulletListButton!)
await userEvent.type(doc, 'item')
await userEvent.click(screen.getByText('Submit'))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
await userEvent.click(screen.getByText('Submit'))
await userEvent.click(screen.getByRole('button', { name: "Submit" }))

Comment on lines +126 to +128
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-describedby', 'id-test-notice')
expect(status).toHaveTextContent(successMessage)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not the status that should have the aria-describedby attribute, it's the element that is described by the status, so in your case the text editor. You have to :

  • add an id attribute on the status element
  • add an aria-describedby="status-id" attribute on the editor
    this way you "link" the status to the editor, and screen readers will read the status when focusing the editor.

As suggested, you can use toHaveAccessibleDescription to check this behavior without reading directly the attributes (test the behavior and not the implementation)

Suggested change
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-describedby', 'id-test-notice')
expect(status).toHaveTextContent(successMessage)
const doc = screen.getByLabelText<HTMLDivElement>('Test')
expect(doc).toHaveAccessibleDescription(successMessage)
expect(screen.getByText(successMessage)).toHaveRole('status')

use this same syntax for the other tests below

}),
className,
)}
data-disabled={disabled ? 'true' : undefined}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so you can remove it ?

onFocus={onFocus}
/>
{error ? (
<AlertCircleIcon
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it normal that the error icon is here ? it looks a bit weird to me

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The icon seems to me you're in the right place, we can see together if you can reproduce it.

return (
<Stack gap="0.5">
{label ? (
<Label htmlFor={id} id={`${id}-label`} required={required}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you have to generate an id with useId() as a fallback if it's not provided in the props, otherwise you have "undefined" ids in the DOM

disabled={disabled}
size="small"
variant={isMarkActive(editorState, strongMark) ? 'filled' : 'ghost'}
onMouseDown={event => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dans la démo de prosemirror on peut cliquer sur les boutons de la toolbar au clavier et à la souris, et le champ garde le focus. Est-ce qu'ils donnent le code de ça pour faire pareil ? C'est un peu gênant de pouvoir focus les boutons au clavier mais qu'on peut pas cliquer dessus

/>

<RichTextInput
aria-label="Bulleted list"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should not need the aria-label in all those examples if it's the same as the label. I think the aria-label on this component should only be used in rare cases like when we don't want the label to be visible, we use the aria-label instead

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants